<!DOCTYPE html>
<title>fragmentDirective.createSelectorDirective</title>
<meta charset="utf-8">
<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/issues/160">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<style>
  .causeScrolling {
    height: 200vh;
  }
</style>
<script>
    function getRange(startNode, startOffset, endNode, endOffset) {
      const range = startNode.ownerDocument.createRange();
      range.setStart(startNode, startOffset);
      range.setEnd(endNode, endOffset);
      return range;
    }

    // Performs a same-document navigation to a text directive whose content is
    // `directive` so that the page attempts to scroll to it. Navigation is
    // performed in the given window `win`, if null uses main frame.
    function scrollToSelectorDirective(directive, win) {
      let w = window;
      if (typeof win != 'undefined')
        w = win;
      w.location.hash = `:~:${directive}`;
    }

    function reset() {
      window.scrollTo(0, 0);
      document.getElementById('iframe').contentWindow.scrollTo(0, 0);
    }

    // Returns true if the center of `element` is within the visual viewport.
    function isInViewport(element) {
      const viewportRect = {
        left: visualViewport.offsetLeft,
        top: visualViewport.offsetTop,
        right: visualViewport.offsetLeft + visualViewport.width,
        bottom: visualViewport.offsetTop + visualViewport.height
      };

      const elementRect = element.getBoundingClientRect();
      const elementCenter = {
        x: elementRect.left + elementRect.width / 2,
        y: elementRect.top + elementRect.height / 2
      };

      return elementCenter.x > viewportRect.left &&
             elementCenter.x < viewportRect.right &&
             elementCenter.y > viewportRect.top &&
             elementCenter.y < viewportRect.bottom;
    }
</script>
<body>
  <div class="causeScrolling"></div>
  <p id="p1">The quick brown fox...</p>
  <img width="100" height="100" src="">
  <p id="p2">...jumped over the lazy dog</p>
  <p id="japanese">このテストへようこそ</p>
  <p id="empty"></p>
  <div class="causeScrolling"></div>
  <p id="p3">quick is an ambiguous word</p>
  <div class="causeScrolling"></div>
  <p id="p4">this sentence &amp; words, contain percent-encoded characters</p>
  <div class="causeScrolling"></div>
  <p id="p5">Here's some whitespace:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;and it's done.</p>
  <div class="causeScrolling"></div>
  <iframe id='iframe' src="resources/simple-frame.html"></iframe>

  <script>
    const p1 = document.getElementById("p1");
    const p2 = document.getElementById("p2");
    const p3 = document.getElementById("p3");
    const p4 = document.getElementById("p4");
    const japanese = document.getElementById("japanese");
    const empty = document.getElementById("empty");
    const initialIFrameURL = document.getElementById('iframe').contentWindow.location.href;

    const test_cases = [
      {
        // Simple sentence within a block.
        range: getRange(p1.firstChild, 0, p1.firstChild, 19),
        range_text: 'The quick brown fox',
        description: 'Simple text run'
      },
      {
        // Range spans multiple blocks
        range: getRange(p1.firstChild, 10, p2.firstChild, 14),
        range_text: 'brown fox...\n  \n  ...jumped over',
        description: 'Cross-block text'
      },
      {
        // Ensure the generator can correctly disambiguate a range that occurs
        // more than once.
        range: getRange(p1.firstChild, 4, p1.firstChild, 9),
        range_text: 'quick',
        description: 'Ambiguous text - first instance'
      },
      {
        // Same as above but this time the range is the other instance, to make
        // sure the generator doesn't just get lucky.
        range: getRange(p3.firstChild, 0, p3.firstChild, 5),
        range_text: 'quick',
        description: 'Ambiguous text - last instance'
      },
      {
        // Ensure the generator correctly percent encodes characters that are
        // part of the text fragment syntax: ampersand, comma, and hyphen.
        range: getRange(p4.firstChild, 5, p4.firstChild, 46),
        range_text: 'sentence & words, contain percent-encoded',
        description: 'Percent encoded set'
      },
      {
        // Ensure the generator expands a text directive that doesn't start/end
        // on a text boundary.
        range: getRange(p3.firstChild, 13, p3.firstChild, 19),
        range_text: 'mbiguo',
        description: 'Non-word boundary'
      },
      {
        // Ensure the generator expands a text directive that doesn't start/end
        // on a text boundary in a non-space broken language like Japanese.
        // (And that the unicode is correctly percent-encoded).
        range: getRange(japanese.firstChild, 6, japanese.firstChild, 9),
        range_text: 'ようこ',
        description: 'Non-word boundary Japanese'
      },
      {
        // Ensure that using getSelection instead of a range works as well.
        range: getRange(p1.firstChild, 0, p1.firstChild, 19),
        range_text: 'The quick brown fox',
        description: 'window.getSelection()',
        use_selection: true
      },
      {
        // Ensure passing a null range rejects the returned promise.
        range: null,
        range_text: null,
        description: 'Null range.',
        rejects_js: TypeError
      },
      {
        // Ensure passing a collapsed range rejects the returned promise.
        range: getRange(p1.firstChild, 1, p1.firstChild, 1),
        range_text: '',
        description: 'Collapsed range.',
        rejects_dom: 'NotSupportedError'
      },
      {
        // Ensure passing a range of just whitespace rejects.
        range: getRange(p5.firstChild, 6, p5.firstChild, 7),
        range_text: ' ',
        description: 'White space.',
        rejects_dom: 'OperationError'
      },
      {
        // Ensure passing a range of just non-breaking whitespace rejects.
        range: getRange(p5.firstChild, 23, p5.firstChild, 26),
        range_text: '\xa0\xa0\xa0',
        description: 'Non-breaking white space.',
        rejects_dom: 'OperationError'
      },
    ];

    onload = () => {
      for (let test of test_cases) {
        promise_test(async (t) => {
          reset();
          let range = test.range;
          let start_element;

          if (range) {
            start_element = (range.startContainer.nodeType == Node.TEXT_NODE)
                ? range.startContainer.parentElement
                : range.startContainer;
            assert_false(isInViewport(start_element), 'PRECONDITION: Target is not in view.');
            assert_equals(range.toString(), test.range_text, 'PRECONDITION: Correct range.');
          }

          if (Boolean(test['use_selection'])) {
            const selection = window.getSelection();
            selection.removeAllRanges();
            selection.addRange(range);
            range = selection;
          }

          if ('rejects_js' in test) {
            return promise_rejects_js(t, test.rejects_js, document.fragmentDirective
                                                                  .createSelectorDirective(range));
          }

          const result_promise = document.fragmentDirective.createSelectorDirective(range);

          if ('rejects_dom' in test) {
            return promise_rejects_dom(t, test.rejects_dom, result_promise);
          } else {
            const result = await result_promise;
            scrollToSelectorDirective(result);

            await t.step_wait(() => window.scrollY > 0, "Wait for scroll");
            assert_true(isInViewport(start_element), '`p1 is scrolled into view.');
          }
        }, test.description);
      }

      // IFrame tests
      {
        function elemP() {
          return frames[0].document.getElementById('iframetext');
        }

        // Simple test that createSelectorDirective works from inside an iframe document.
        promise_test(async (t) => {
          reset();
          const iframeDoc = frames[0].document;
          assert_equals(iframeDoc.scrollingElement.scrollTop, 0, 'PRECONDITION: Iframe unscrolled');

          const range = getRange(elemP().firstChild, 0, elemP().firstChild, 21);
          assert_equals(range.toString(), 'Text inside an iframe', 'PRECONDITION: Correct range.');

          const result = await iframeDoc.fragmentDirective.createSelectorDirective(range);
          scrollToSelectorDirective(result, frames[0]);
          await t.step_wait(() => window.scrollY > 0, "Wait for scroll");
          assert_greater_than(iframeDoc.scrollingElement.scrollTop, 100, 'iframe text scrolled into view');
        }, 'Generate selector within iframe');

        // Ensure that the range passed to createSelectorDirective must be from
        // the same document as the fragmentDirective being used.
        promise_test(async (t) => {
          reset();
          const range = getRange(elemP().firstChild, 0, elemP().firstChild, 21);
          assert_equals(range.toString(), 'Text inside an iframe', 'PRECONDITION: Correct range.');

          // Try creating a selector in the top document using the iframe range.
          const result_promise = document.fragmentDirective.createSelectorDirective(range);
          return promise_rejects_dom(t, "WrongDocumentError", result_promise);
        }, 'Range from outside document');

        // Ensure that createSelectorDirective doesn't work on a document that
        // isn't attached to a frame.
        promise_test(async (t) => {
          reset();
          let doc = document.implementation.createHTMLDocument("New Document");
          let p = doc.createElement("p");
          p.textContent = "This is a document without a frame.";
          doc.body.appendChild(p);

          const range = getRange(p.firstChild, 0, p.firstChild, 7);
          assert_equals(range.toString(), 'This is', 'PRECONDITION: Correct range.');

          // Try creating a selector in the unattached document.
          const result_promise = doc.fragmentDirective.createSelectorDirective(range);
          return promise_rejects_dom(t, "InvalidStateError", result_promise);
        }, 'Detached document');

        // Ensure that createSelectorDirective doesn't work on a document that
        // has been replaced.
        promise_test(async (t) => {
          reset();
          const iframeDoc = frames[0].document;
          assert_equals(iframeDoc.scrollingElement.scrollTop, 0, 'PRECONDITION: Iframe unscrolled');

          const range = getRange(elemP().firstChild, 0, elemP().firstChild, 21);
          assert_equals(range.toString(), 'Text inside an iframe', 'PRECONDITION: Correct range.');

          frames[0].location = 'about:blank';
          await t.step_wait(() => frames[0].document != iframeDoc, "Wait for about:blank");

          const result = await iframeDoc.fragmentDirective.createSelectorDirective(range);
          assert_equals(result, undefined, 'createSelectorDirective returns undefined');
        }, 'Execution context destroyed');
      }
    };
  </script>
</body>
